回想一下我们在ppprofiler通道开发为插件一节中，从LLVM源码树中作为插件开发的ppprofiler通道。这里，因为LLVM工具可以加载插件，将学习如何将此通道与LLVM工具(如opt和clang)一起使用。

我们先看一下opt。

\mySamllsection{opt中运行通道插件}

要使用这个新插件，需要一个包含LLVM IR的文件。要做到这一点，最简单的方法是翻译一个C程序，例如一个“Hello World”风格的程序:

\begin{cpp}
#include <stdio.h>

int main(int argc, char *argv[]) {
    puts("Hello");
    return 0;
}
\end{cpp}

使用clang编译hello.c文件:

\begin{shell}
$ clang -S -emit-llvm -O1 hello.c
\end{shell}

会得到一个非常简单的IR文件，hello.ll。其中包含以下代码:

\begin{shell}
$ cat hello.ll
@.str = private unnamed_addr constant [6 x i8] c"Hello\00",
        align 1

define dso_local i32 @main(
            i32 noundef %0, ptr nocapture noundef readnone %1) {
    %3 = tail call i32 @puts(
                    ptr noundef nonnull dereferenceable(1) @.str)
    ret i32 0
}
\end{shell}

这就足够测试通道了。

要运行该通道，必须提供几个参数。首先，需要通过-{}-load-pass-plugin选项告诉opt加载动态库。要运行单个通道，必须指定-{}-passes选项。使用hello.ll文件作为输入，可以运行如下命令:

\begin{shell}
$ opt --load-pass-plugin=./PPProfile.so \
      --passes="ppprofiler" --stats hello.ll -o hello_inst.bc
\end{shell}

若启用了统计信息生成功能，将看到如下输出:

\begin{shell}
===--------------------------------------------------------===
... Statistics Collected ...
===--------------------------------------------------------===

1 ppprofiler - Number of instrumented functions.
\end{shell}

否则，将提示未启用统计:

\begin{shell}
Statistics are disabled. Build with asserts or with
-DLLVM_FORCE_ENABLE_STATS
\end{shell}

文件hello\_inst.bc是结果，可以使用llvm-dis工具将该文件转换为可读的IR。正如预期的那样，将会看到对\_\_ppp\_enter()和\_\_ppp\_exit()函数的调用，以及一个新的函数名常量:

\begin{shell}
$ llvm-dis hello_inst.bc -o –
@.str = private unnamed_addr constant [6 x i8] c"Hello\00",
        align 1
@0 = private unnamed_addr constant [5 x i8] c"main\00",
     align 1
define dso_local i32 @main(i32 noundef %0,
                            ptr nocapture noundef readnone %1) {
    call void @__ppp_enter(ptr @0)
    %3 = tail call i32 @puts(
                    ptr noundef nonnull dereferenceable(1) @.str)
    call void @__ppp_exit(ptr @0)
    ret i32 0
}
\end{shell}

这看起来已经很不错了!若能将此IR转换为可执行文件并运行，，需要为调用函数提供实现。

通常，运行时对特性的支持比将该特性添加到编译器本身要复杂得多。当调用\_\_ppp\_enter()和\_\_ppp\_exit()时，可以将其视为事件。为了以后分析数据，有必要保存事件。可获得的基本数据是该类型的事件、函数的名称及其地址以及时间戳。没有点技巧，处理起来并不是那么容易。

创建一个名为runtime.c的文件，内容如下:

\begin{enumerate}
\item
需要文件I/O、标准函数和时间支持。这是由以下头文件提供的:

\begin{cpp}
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
\end{cpp}

\item
对于该文件，需要一个文件描述符。当程序结束时，该文件描述符应该正确关闭:

\begin{cpp}
static FILE *FileFD = NULL;

static void cleanup() {
    if (FileFD == NULL) {
        fclose(FileFD);
        FileFD = NULL;
    }
}
\end{cpp}

\item
为了简化运行时，只对输出使用固定的名称。若文件未打开，则打开文件并注册清理功能:

\begin{cpp}
static void init() {
    if (FileFD == NULL) {
        FileFD = fopen("ppprofile.csv", "w");
        atexit(&cleanup);
    }
}
\end{cpp}

\item
可以调用clock\_gettime()函数来获取时间戳，CLOCK\_PROCESS\_CPUTIME\_ID参数返回该进程消耗的时间。注意，并非所有系统都支持此参数。若有必要，可以使用其他时钟之一，比如CLOCK\_REALTIME:

\begin{cpp}
typedef unsigned long long Time;

static Time get_time() {
    struct timespec ts;
    clock_gettime(CLOCK_PROCESS_CPUTIME_ID, &ts);
    return 1000000000L * ts.tv_sec + ts.tv_nsec;
}
\end{cpp}

\item
现在，很容易定义\_\_ppp\_enter()函数。只需确保文件是打开的，获取时间戳，并写入事件:

\begin{cpp}
void __ppp_enter(const char *FnName) {
    init();
    Time T = get_time();
    void *Frame = __builtin_frame_address(1);
    fprintf(FileFD,
            // "enter|name|clock|frame"
            "enter|%s|%llu|%p\n", FnName, T, Frame);
}
\end{cpp}

\item
\_\_ppp\_exit()函数仅在事件类型方面有所不同:

\begin{cpp}
void __ppp_exit(const char *FnName) {
    init();
    Time T = get_time();
    void *Frame = __builtin_frame_address(1);
    fprintf(FileFD,
    // "exit|name|clock|frame"
    "exit|%s|%llu|%p\n", FnName, T, Frame);
}
\end{cpp}

\end{enumerate}

这是一个非常简单的运行时支持实现。在我们尝试之前，应该对实现做一些注释，因为有几个部分明显存在问题。

首先，因为只有一个文件描述符，对它的访问不受保护，所以该实现线程不安全。尝试在多线程程序中使用此运行时实现，很可能导致输出文件中的数据受到干扰。

此外，省略了检查与I/O相关的函数的返回值，这可能导致数据丢失。

最重要的是，事件的时间戳并不精确。调用函数已经增加了开销，但在该函数中执行I/O操作会使情况变得更糟。原则上，可以匹配函数的进入和退出事件，并计算函数的运行时。但这个值本身就有缺陷，因为可能包含I/O所需的时间，所以不要相信这里记录的时间。

尽管有这些缺陷，这个小的运行时文件允许产生一些输出。将检测文件的位码与包含运行时代码的文件一起编译，并运行生成的可执行文件:

\begin{shell}
$ clang hello_inst.bc runtime.c
$ ./a.out
\end{shell}

这将在目录中生成一个名为ppprofile.csv的新文件，其中包含以下内容:

\begin{shell}
$ cat ppprofile.csv
enter|main|3300868|0x1
exit|main|3760638|0x1
\end{shell}

Cool – 新通道和运行时似乎可以工作了!

\begin{myTip}{指定通道流水线}
使用-{}-passes选项，不仅可以命名单个通道，还可以描述整个流水线。例如，优化级别2的默认流水线名为default<O2>。可以使用-{}-passes="ppprofile,default<O2>"参数在默认管道之前运行ppprofile通道。注意，这种流水线描述中的通道名称，类型必须相同。
\end{myTip}

现在，让我们在clang中使用新通道。

\mySamllsection{将新通道插入clang}

上一节中，了解了如何使用opt运行单个通道。若需要调试通道，对于真正的编译器，步骤不应该那么复杂。

为了获得最佳结果，编译器需要按照一定的顺序运行优化通道。LLVM通道管理器有默认的执行顺序，这也称为默认传递管道。使用opt时，可以使用-passes选项指定不同的通道流水线。这很灵活，但对用户来说也很复杂。大多数情况下，只想在特定的点添加一个新的通道，例如在优化通道运行之前或在循环优化过程结束时，这些点称为扩展点。PassBuilder类允许在扩展点注册通道。可以调用registerPipelineStartEPCallback()方法，在优化通道前添加一个通道，这正是我们ppprofiler通道的位置。优化过程中，函数可能内联，通道将错过这些内联函数。相反，在优化通道之前运行通道，可以保证所有函数都检测到。

要使用这种方法，需要在通道插件中扩展RegisterCB()函数。将以下代码添加到函数中:

\begin{cpp}
PB.registerPipelineStartEPCallback(
    [](ModulePassManager &PM, OptimizationLevel Level) {
        PM.addPass(PPProfilerIRPass());
    });
\end{cpp}

每当通道管理器填充默认通道流水线时，都会调用扩展点的所有回调，只需在这里添加新的通道即可。

要将插件加载到clang中，可以使用-fpass-plugin选项。创建hello.c文件的可执行文件就很简单:

\begin{shell}
$ clang -fpass-plugin=./PPProfiler.so hello.c runtime.c
\end{shell}

请运行可执行文件，并验证运行是否创建了ppprofile .csv文件。

\begin{myNotic}{Note}
因为通过检查特殊函数是否尚未在模块中声明，所以没有检测runtime.c文件。
\end{myNotic}

但能扩展到更大的程序吗?假设为第5章构建tinylang编译器的二进制文件。需要怎么做呢?可以在CMake命令行中传递编译器和链接器标志，这正是我们所需要的。

C++编译器的标志在CMAKE\_CXX\_FLAGS变量中给出，所以在CMake命令行中指定以下命令会将新的通道添加到所有编译器运行中:

\begin{shell}
-DCMAKE_CXX_FLAGS="-fpass-plugin=<PluginPath>/PPProfiler.so"
\end{shell}

请将<PluginPath>替换为动态库的绝对路径。

类似地，指定下列语句将添加runtime.o文件到每个链接器调用，请将<RuntimePath>替换为runtime.c编译版本的绝对路径:

\begin{shell}
-DCMAKE_EXE_LINKER_FLAGS="<RuntimePath>/runtime.o"
\end{shell}

当然，这需要clang作为构建编译器。确保clang用作构建编译器的最快方法是设置CC和CXX环境变量:

\begin{shell}
export CC=clang
export CXX=clang++
\end{shell}

有了这些选项，第5章中的CMake配置应该像往常一样运行。

构建了tinylang可执行文件之后，可以使用示例Gcd.mod文件运行。还需要创建ppprofile.csv文件，这次有超过44,000行!当然，拥有这样一个数据集会提出一个问题，即是否可以从中获得有用的东西。例如，获得10个最常调用的函数的列表，以及调用计数和在函数中花费的时间，将是有用的信息。幸运的是，在Unix系统上，有一些工具可以提供帮助。让我们构建一个简短的管道来匹配进入事件和退出事件，对函数进行计数，并显示前10个函数。awk Unix工具可以帮助完成这些步骤中的大部分。

要将进入事件与退出事件匹配，必须将进入事件存储在记录关联映射中。匹配退出事件时，查找存储的进入事件，并写入新记录。发出的行包含进入事件的时间戳、退出事件的时间戳以及两者之间的差异。必须把这个放进join.awk文件中:

\begin{shell}
BEGIN { FS = "|"; OFS = "|" }
/enter/ { record[$2] = $0 }
/exit/ { split(record[$2],val,"|")
         print val[2], val[3], $3, $3-val[3], val[4] }
\end{shell}

为了计算函数调用和执行，使用了两个关联映射，count和sum。在count中，函数调用时计数，而在sum中，增加执行时间。最后，映射转储，可以将其放入avg.awk文件中:

\begin{shell}
BEGIN { FS = "|"; count[""] = 0; sum[""] = 0 }
{ count[$1]++; sum[$1] += $4 }
END { for (i in count) {
        if (i != "") {
            print count[i], sum[i], sum[i]/count[i], I }
} }
\end{shell}

运行这两个脚本之后，可以按降序对结果进行排序，然后可以从文件中取出前10行。仍然可以改进函数名，\_\_ppp\_enter()和\_\_ppp\_exit()，这时依旧很难阅读。使用llvm-cxxfilt工具，可以修改这些名称。demangle.awk脚本如下所示:

\begin{shell}
{ cmd = "llvm-cxxfilt " $4
    (cmd) | getline name
    close(cmd); $4 = name; print }
\end{shell}

要获得前10个函数调用，可以运行以下命令:

\begin{shell}
$ cat ppprofile.csv | awk -f join.awk | awk -f avg.awk |\
  sort -nr | head -15 | awk -f demangle.awk
\end{shell}

下面是输出中的一些示例行:

\begin{shell}
446 1545581 3465.43 charinfo::isASCII(char)
409 826261 2020.2 llvm::StringRef::StringRef()
382 899471 2354.64
            tinylang::Token::is(tinylang::tok::TokenKind) const
171 1561532 9131.77 charinfo::isIdentifierHead(char)
\end{shell}

第一个数字是函数的调用次数，第二个数字是累计执行时间，第三个数字是平均执行时间。如前所述，不要相信时间值，但调用计数应该是准确的。

目前，我们已经实现了一个新的检测通道，或者作为一个插件，或者作为LLVM的补充，并在一些实际场景中使用了它。下一节中，将探讨如何在编译器中设置一个优化流水线。


